NodeJS Headless 动态漏扫爬虫学习记录(爬虫篇)

文章首发先知社区:https://xz.aliyun.com/t/7064

在两年前谷歌推出了一个Headless Chrome NodeJS API:Puppeteer,后来Github一个大牛用Python封装了一套api,作为一个第三方api:Pyppeteer

在去年的时候,尝试过用Pyppeteer写过动态爬虫,Python版由于是第三方一个作者封装的,更新很慢,落后官方版本很多,很多迷之BUG,比如CDP协议去操作远程chromium,很容易中断导致一堆僵尸进程的chromium关不掉。虽然最后还是顶着各种bug,写成一个勉强能用的工具,但在服务器上很吃内存,一方面也是因为写的任务调度机制也有一些问题,最后服役了许多天天,不想维护了,捡了几个漏洞就退休了。后来在平时的工作和学习中频频接触到nodeJS,于是就趁着这段时间用nodejs重新实现一遍。

JS基础

JS中的事件模型

分为:内联、DOM0级、DOM2级事件
-w1016

JS原型链介绍

Js是一种基于原型的语言,每一个对象都有一个原型对象,对象以其原型为模板、从原型继承方法和属性。原型对象也可能拥有原型,一层一层、以此类推。
在传统的面向对象编程中,我们首先会定义“类”,此后创建对象实例时,类中定义的所有属性和方法都被复制到实例中。但在 js 中并不是像这样复制,而是在对象实例和类之间之间建立一个链接。
demo:

1
2
3
4
5
function Cat() {
this.color = 'test'
}
var cat = new Cat()
console.log(cat.__proto__ === Cat.prototype) // true

-w909
在 JavaScript 中,如果想访问某个属性,首先会在实例对象(cat)的内部寻找,如果没找到,就会在该对象的原型(cat._proto_,即 Cat.prototype)上找,我们知道,对象的原型也是对象,它也有原型,如果在对象的原型上也没有找到目标属性,则会在对象的原型的原型(Cat.prototype._proto_)上寻找,以此内推,直到找到这个属性或者到达了最顶层。在原型上一层一层寻找,这便是原型链了。

如何抓取更多的URL

几种思路,可以直接使用正则抓取,也可以解析各种含有链接的标签,也就是src,href属性等。
当然这些都有一定的缺陷,比如相对路径需要单独去处理成完整URl,有的使用的js跳转,而不把URl写到标签内等等。另一种思路即使用动态爬虫的思路,Hook JS,通过触发各种事件信息收集URL。这里计划第一版爬虫先实现简易的URL抓取,之后再进一步优化。首先最常想到的是使用正则抓取,其次可以利用Headless的优势,将动态JS渲染的链接标签、属性抓取。

收集src、href属性的标签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function getSrcAndHrefLinks(nodes) {
let result = [];
for(let node of nodes){
let src = node.getAttribute("src");
let href = node.getAttribute("href");
let action = node.getAttribute("action");
if (src){
result.push(src)
}
if (href){
result.push(href);
}
if(action){
result.push(action);
}
}
return result;
}
const links = await page.$$eval('[src],[href],[action]', getSrcAndHrefLinks);

爬行结果:
-w1366

接着通过简单的URL去重、清洗,爬虫便可以进行迭代爬行了。

经过一番测试后发现,对于下面这种页面URL抓取是会有遗漏的:
-w1547

有的将跳转操作全写入了js事件中,或者有的要进行页面滚动JS才会进一步渲染,无疑遗漏了很多URL。解决这些问题的关键在于模拟用户操作,而用户操作的本质则为触发各种DOM事件。所以接下来需要解决的问题在于收集各种DOM事件,以及去触发它们。

收集DOM事件

在学习收集DOM事件的过程中参考了9ian1i师傅以及fate0师傅文章,很感谢前辈们的拓荒。

Hook事件

注册事件分为DOM0和DOM2事件,使用方法不同,收集方法也有差异。这里简单介绍了两者的差异DOM0级事件和DOM2级事件区别。以及JavaScript Prototype Chain 原型链学习
DOM0
对于DOM0的事件监听,可以修改所有节点的相关属性原型,设置其访问器属性。
demo:

1
2
3
4
5
6
7
8
9
10
function dom0Hook(that, event_name) {
console.log("tagname: " + that.tagName + ", event_name:" + event_name);
}
Object.defineProperties(HTMLElement.prototype, {
onclick: {set: function(newValue){onclick = newValue;dom0Hook(this, "click");}},
onchange: {set: function(newValue){onchange = newValue;dom0Hook(this, "change");}}
});
$0 = document.getElementsByTagName("a");
$0[0].onclick = function(){console.log("a")
}

-w646

DOM2
DOM2级事件Hook,可以通过修改addEventListener的原型即可:

1
2
3
4
5
let oldEvent = Element.prototype.addEventListener;
Element.prototype.addEventListener = function(event_name, event_func, useCapture) {
console.log("tagname: " + this.tagName + ", event_name:" + event_name);
oldEvent.apply(this, arguments);
};

-w722

内联事件
除了上述两种绑定事件的办法,还有通过写在标签内的内联事件,无法通过Hook来收集。比如:

1
<div id="test" onclick="alert('1')">123</div>

解决办法是通过遍历节点,执行on事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function trigger_inline(){
var nodes = document.all;
for (var i = 0; i < nodes.length; i++) {
var attrs = nodes[i].attributes;
for (var j = 0; j < attrs.length; j++) {
attr_name = attrs[j].nodeName;
attr_value = attrs[j].nodeValue;
if (attr_name.substr(0, 2) == "on") {
console.log(attrs[j].nodeName + ' : ' + attr_value);
eval(attr_value);
}
if (attr_name in {"src": 1, "href": 1} && attrs[j].nodeValue.substr(0, 11) == "javascript:") {
console.log(attrs[j].nodeName + ' : ' + attr_value);
eval(attr_value.substr(11));
}
}
}
}

-w1064

或者TreeWalker获取全部节点,用dispatchEvent挨个触发事件
而DOM0、DOM2级事件通过收集到的标签和事件名依次触发即可。

导航锁定

触发事件的过程中,可能会被意外的导航请求给中断操作,所以我们应当取消非本页面的导航请求,避免造成漏抓。
前端JS跳转
取消跳转操作,记录跳转URL,但是Chrome不允许我们通过Object.defineProperty重定义window.Location操作,即无法通过Hook获取跳转的URL。
-w705

搜索了一些资料之后大致有下边一些解决办法:

但最后我选择了为漏扫动态爬虫定制的浏览器,后边会细说。

后端跳转
请求体无内容,则跟进;请求体有内容,则渲染页面,记录跳转url。

表单填充

锁定重置表单事件

1
2
3
4
5
HTMLFormElement.prototype.reset = function() {
console.log("cancel reset form")
};
Object.defineProperty(HTMLFormElement.prototype, "reset", {"writable": false, "configurable": false}
);

挖坑

为漏扫动态爬虫定制的浏览器

解决这个前端导航hook问题的时候,发现github上有一个大牛通过修改源码实现了一个为漏扫定制版的Chrome。作者通过修改chromium源码实现了导航的Hook,禁止页面的天锻跳转并收集其跳转的URL,并且通过底层hook了所有非默认事件,为我们开发提供了很多便利。

但还是有一些小的地方需要我们自己优化一下,会锁定导航自动收集前端跳转URL,但不会处理后端的Location,这里我们用一个拦截器去实现,记录后端跳转,加入扫描队列:

1
2
3
4
5
6
7
8
9
10
await page.on('response', interceptedResponse =>{
let status = interceptedResponse.status();
if(status.toString().substr(0,2) === "30"){
console.log("url: " + interceptedResponse.url());
console.log("status: " + status);
console.log("headers: " + interceptedResponse.headers().location);
// 添加进任务队列
cluster.queue(interceptedResponse.headers().location);
}
});

事件触发&收集结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function executeEvent() {
var firedEventNames = ["focus", "mouseover", "mousedown", "click", "error"];
var firedEvents = {};
var length = firedEventNames.length;
for (let i = 0; i < length; i++) {
firedEvents[firedEventNames[i]] = document.createEvent("HTMLEvents");
firedEvents[firedEventNames[i]].initEvent(firedEventNames[i], true, true);
}
var eventLength = window.eventNames.length;
for (let i = 0; i < eventLength; i++) {
var eventName = window.eventNames[i].split("_-_")[0];
var eventNode = window.eventNodes[i];
var index = firedEventNames.indexOf(eventName);
if (index > -1) {
if (eventNode != undefined) {
eventNode.dispatchEvent(firedEvents[eventName]);
}
}
}
let result = window.info.split("_-_");
result.splice(0,1);
return result;
}

-w1679

添加cookie

对于使用SSO单点站点体系而言,可以在开始爬行之前指定一段cookie,比如从文本中读取。但是对于爬行目标较为多且SSO的覆盖面有限的情况下,就得使用数据库了。在测试过程中遇到了另一个问题,就是并发过高,或者发送有害的payload,会有Cookie失效的问题,这里想到了一种比较实用的解决办法,写一个浏览器插件及时将当前页面的cookie同步到服务端数据库,然后爬虫定期从数据库中更新最新的cookie。

Chrome插件同步cookie

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function updateCookie(domain, name , value){
let api = "https://127.0.0.1/add-cookie";
$.post(api, {
"domain": domain,
"name": name,
"value": value,
}, function (data, status) {
console.log(status);
});
}
/*
* doc: https://developer.chrome.com/extensions/cookies
*/
chrome.cookies.onChanged.addListener((changeInfo) =>{
// 记录Cookie增加,Cookie更新分两步,第一步先删除,第二步再增加
if(changeInfo.removed === false){
updateCookie(changeInfo.cookie.name, changeInfo.cookie.value, changeInfo.cookie.domain);
}
});

相似URL去重

去重在爬虫中是一个较为核心功能,规则过于宽松可能导致爬行不完或者说做一些无意义的重复爬行,规则过于严格则可能导致抓取结果过少,影响后续抓取和漏洞检测。去重一般分为两步对爬行队列去重,或者对结果集去重。

在解决这个问题的时候,参考了Fr1day师傅【技术分享】浅谈动态爬虫与去重的URL去重思路。不失为一种比较便捷,能基本满足当前需求的一种解决办法。

参数分析
大致有以下几种参数:类型int、hash、中文、URL编码

1
2
3
4
?m=home&c=index&a=index
?type=202cb962ac59075b964b07152d234b70
?id=1
?msg=%E6%B6%88%E6%81%AF

根据不同的类型对其进行处理:

  1. 纯字母:中参数的值表示不同的路由功能,需要对这种参数进行保留
  2. 字母数字混合:可能是用户的hash,也可能具有路由功能,可根据任务量情况选择性保留
  3. 纯数字、URl编码:进行去重

处理结果即:

1
2
3
4
?m=home&c=index&a=index
?type={hash}
?id={int}
?msg={urlencode}

然后在数据库中将相同的清洗掉即可。

相似页面去重

相似度计算,监控资产变化
网页结构相似度:https://xueshu.baidu.com/usercenter/paper/show?paperid=232b0da253211ecf9e2c85cb513d0bd3&site=xueshu_se

挖坑

性能优化

图片资源优化

禁止浏览器加载图片 => 返回一个fake img
实际测试过程中,有的网站在加载图片失败后,会尝试重新加载,这样会陷入一个死循环,导致发送大量数据包,占用性能。
-w598

-w1679
代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const browser = await puppeteer.launch(launchOptions);
const page = await browser.newPage();
await preparePage(page);
await page.setRequestInterception(true); // 开启拦截功能
await page.on('request', interceptedRequest => {
// 拦截图片请求
if (interceptedRequest.resourceType() === 'image' || interceptedRequest.url().endsWith('.ico')) {
//console.log(`abort image: ${interceptedRequest.url()}`);
let images = fs.readFileSync('public/image.png');
interceptedRequest.respond({
'contentType': ' image/png',
'body': Buffer.from(images)
});
}
else {
interceptedRequest.continue();
}

拦截logout请求

避免爬虫爬行到登出链接,导致Cookie失效,这里做一个简单的拦截:

1
2
3
4
5
6
7
8
await page.on('request', interceptedRequest => {
if(interceptedRequest.url().indexOf("logout") !== -1){
interceptedRequest.abort();
}
else{
interceptedRequest.continue();
}
});

puppeteer并发异步调度方案

简单粗暴,这里使用puppeteer-cluster库解决单Chrome多tab并发需求,也可以参考使用guimaizi师傅的demo:puppeteer异步并发方案

开源

这里边其实还有很多坑要填,师傅们多指点交流~
开源链接:https://github.com/Passer6y/CrawlerVuln
(求star

待实现的需求

弹窗取消

代码注入时间
链接收集有点不太全,触发完了事件后得等一会再收集url。

待解决的bug
page.once 确定抓取链接时间